Next.js の "use client" とはなんなのか
SSR / CSR / use client。Next.js(と一部Nuxt)を触っていて混乱した部分をまとめてみる
なぜハイドレーションエラーは起きるのか、につながる
(Supabase + React Flow みたいな構成をしたときのメモ)
"use client" は「クライアントで動くコンポーネント」という宣言
クライアントで動くように、JSをバンドルするよ、という宣言
重要なのは 同時に SSR も行われている という点
"use client" を書いても、サーバーでコンポーネント関数は実行されてHTMLが生成される
ブラウザに送られるのは静的HTMLとJSバンドルの両方
code:_
layout.tsx(Server Component)
└─ page.tsx("use client" ← ここが境界線)
└─ Canvas.tsx(宣言なくてもClient扱いになる)
└─ @xyflow/react(同様)
"use client" なしのServer Componentは、JSがブラウザに送られない
onClick等も動かない(HTMLとして出力されるだけ)
比較表: https://zenn.dev/saku2323/articles/about-nextjs-useclient
うーん、直感的ではない……
use client ってクライアントサイドに全投げかと思っていた
実際に何が起きているか
code:_
サーバー
コンポーネント関数を実行("use client"でも実行される)
├─ useState の初期値でHTMLを生成
├─ useEffect はスキップ
├─ console.log は Node.js ターミナルに出る(ブラウザコンソールではない)
└─ JSX → HTML文字列(renderToString)
↓ HTMLとRSCペイロード(self.__next_f)を同時に送信
ブラウザ
HTMLを即表示
JSバンドルをダウンロード
Hydrate(Reactがイベントハンドラ等をDOMに付与)
useEffect 実行
インタラクティブになる
HTMLを見ると <script>self.__next_f.push([...])</script> が埋め込まれている
これが RSC Flight フォーマット(本当?)というらしく(Hydration用の設計図)、コンポーネントツリーの構造・どのJSファイルが必要か・Server Componentのデータ等が含まれている
静的HTMLだけではReactがHydrateできないので、両方が必要
SSRされる/されないの判断基準
table:
コード SSR時の挙動
JSX構造 実行・HTML化される
useState(初期値) 初期値でHTML生成
useEffect スキップ
onClick 等のイベントハンドラ HTML属性として出力されるが動かない
window / document undefined(ガード必要)
条件分岐 初期stateで評価した結果のみ出力
dynamic({ssr:false}) / <ClientOnly> スキップ、プレースホルダーのみ出力
console.log の出力
"use client" でも、サーバーサイドでも実行がされている例
code:tsx
"use client";
export default function Page() {
console.log("ここ"); // → Node.js ターミナルに出る(SSR時。npm run devした直後、HMRでは出ない)
useEffect(() => {
console.log("ここ"); // → ブラウザコンソールにのみ出る
}, []);
}
たとえば、うっかりwindowとか書くとエラーが出てしまう
https://scrapbox.io/files/69a7605ff1c53c173221c094.png
dynamic({ssr: false}) は何をするのか
ssr: false を指定した場合、実際のHTMLには以下が出力される (use client)
↓コンポーネントをdynamicで読み込んでみる
code: tsx
// Dynamic import to avoid SSR issues with React Flow
const Canvas = dynamic(() => import("@/components/Canvas"), { ssr: false });
↓書き出されたもの
code:html
<!--$!-->
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">...</template>
<!--/$-->
仮置きっぽい template だけ出ている。コンポーネントのJSがロードされてから初めてここにDOMが挿入される
dynamic を通常の import に変えてみると、普通にHTMLが出力された
→ use client でも、静的な部分はSSRされているっぽい
ただしSupabaseからクライアント側でfetchするSSR時点ではノードは空っぽ。CSRでfetchしてからノードが入る
code:html
<div data-testid="rf__wrapper" class="react-flow light">
<div class="react-flow__renderer">
<div class="react-flow__viewport">
<div class="react-flow__edges"></div>
<div class="react-flow__nodes"></div>
← ノードは空
</div>
</div>
<svg class="react-flow__background">...</svg>
<div class="react-flow__controls">...</div>
<div class="react-flow__minimap">...</div>
</div>
SSR正式対応しているライブラリだと、クラッシュせずにサーバーでレンダリングされる
対応していないと、ハイドレーションエラーになったり、500 エラーになったりする
<head> を見てみると、通常importでは @xyflow/react と @xyflow/system が初期ロード対象に追加されていた
→ 遅延読み込みにつかえるっぽいけど素直に他の手段を取ったほうが良さげに見える
Nuxt の <ClientOnly> との対応
code:tsx
// Next.js
const Canvas = dynamic(() => import("./Canvas"), { ssr: false });
// → SSR時はスキップ、<!--$!--> のみ出力
// → クライアントでJSダウンロード後にレンダリング
code:vue
<!-- Nuxt -->
<ClientOnly>
<Canvas />
</ClientOnly>
同じ概念。<ClientOnly> は "use client" とは別物で、「このコンポーネントをSSRから除外する」という指定
Nuxtでは全コンポーネントがデフォルトでSSR対象なので、除外したい場合に明示的に使う
ハイドレーションエラーが起きる条件
SSRで生成されたHTMLとクライアントの初回レンダリング結果が一致しないと怒られる
code:tsx
// NG: Math.random()はサーバーとクライアントで値が違う
const val = useState(() => Math.random());
// NG: typeof window はサーバーでは undefined なので false になる
// クライアントでは true → 不一致
const isClient = useState(typeof window !== "undefined");
code:tsx
// OK: 固定値なのでどちらも同じ結果になる
const isLoading = useState(true);
// OK: useEffect はHydrate完了後に実行されるので不一致にならない
const show, setShow = useState(false);
useEffect(() => {
setShow(true);
}, []);
要するに「副作用(useEffect)の外側=純粋なレンダリング部分」はSSRとクライアントで同じ結果にする必要がある
副作用の中は何をやってもおk
useEffect を「副作用(side effect)」と呼ぶ理由はここ(純粋なレンダリング外)ってことなのね
code:_
SSR出力 ══ Hydrate時の初回レンダリング ← ここが一致必須
↓
useEffect 実行
↓
ここからは自由
Next.js と Nuxt の設計思想の違い
table:
Next.js App Router Nuxt
デフォルト Server Component(JSバンドルなし) Universal(SSR+CSR両対応)
クライアント指定 "use client" で明示 宣言不要
サーバーのみ "use client" を書かない *.server.vue
クライアントのみ dynamic({ssr:false}) <ClientOnly>
データfetch(SSR) Server Componentで await fetch() useFetch() / useAsyncData()
データfetch(CSR) useEffect onMounted
Nuxtは「全コンポーネントをサーバーでもクライアントでも動くように作る」というUniversal思想
Next.jsは「デフォルトはサーバーのみ、クライアントが必要な部分を明示する」という思想
クライアントfetchで比べるとどちらも同じ
「NuxtはSSRが優れている」という話をよく見るけど、クライアントfetchの前提で比べると挙動は同じ
table:
Next.js Nuxt
クライアントfetch useEffect + Zustand onMounted + Pinia
SSR出力 ローディング画面 ローディング画面
Hydrate後 Supabaseからデータ取得 同左
ブラウザ専用ライブラリ除外 dynamic(ssr:false) <ClientOnly>
Zustand と SSR の注意点
Zustandはモジュールレベルのシングルトンなので、サーバー上で複数リクエストをまたいで同じインスタンスが使われる可能性がある、らしい
code:ts
export const useCanvasStore = create((set) => ({
isLoading: true, // 固定初期値なのでSSRとHydrateで一致する
}));
ただし状態変更を useEffect(クライアントのみ)でしか行わなければ実害はない、とてもややこしい
Nuxt + Pinia はリクエストごとにストアインスタンスを分離する設計になっているので、サーバー側で状態変更するユースケースではPiniaの方が安全、らしい
よくわかっていない
環境変数とSSR/CSR
prexif: NEXT_PUBLIC_ があるものはクライアントJSバンドルにハードコードされる
ないものはサーバーでしか参照できない(クライアントでは undefined)
table:
NEXT_PUBLIC_ あり NEXT_PUBLIC_ なし
クライアントJSバンドルに含まれる あり(ハードコード) なし
SSG HTMLに値が含まれる あり なし
Server Component で使える あり あり(サーバーのみ)
"use client" 内で使える あり undefined になる
ライフサイクルと実行タイミング
code:_
サーバー ブラウザ
────────────────────────────────────────
コンポーネント関数実行
JSX → HTML文字列
RSCペイロード生成
│
└── 両方を同時に送信 ──→ HTMLを即表示
JSバンドルをダウンロード
Hydrate
(SSRとの一致チェック)
useEffect / onMounted 実行
ここから何でもOK
ユーザー操作 → 再レンダリング
こんな感じかな
#技術メモ #Next.js #React